package com.wisdom.collect.modbus.io;

import com.fazecast.jSerialComm.SerialPort;
import com.wisdom.collect.modbus.Modbus;
import com.wisdom.collect.modbus.ModbusIOException;
import com.wisdom.collect.modbus.msg.ModbusMessage;
import com.wisdom.collect.modbus.msg.ModbusRequest;
import com.wisdom.collect.modbus.msg.ModbusResponse;
import com.wisdom.collect.modbus.net.AbstractModbusListener;
import com.wisdom.collect.modbus.net.AbstractSerialConnection;
import com.wisdom.collect.modbus.util.ModbusUtil;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;


public abstract class ModbusSerialTransport extends AbstractModbusTransport {

    private static final Logger logger = LoggerFactory.getLogger(ModbusSerialTransport.class);

    static final int FRAME_START = 1000;

    static final int FRAME_END = 2000;

    private AbstractSerialConnection commPort;
    boolean echo = false;     // require RS-485 echo processing
    private final Set<AbstractSerialTransportListener> listeners = Collections.synchronizedSet(new HashSet<AbstractSerialTransportListener>());

    @Override
    public ModbusTransaction createTransaction() {
        ModbusSerialTransaction transaction = new ModbusSerialTransaction();
        transaction.setTransport(this);
        return transaction;
    }

    @Override
    public void writeResponse(ModbusResponse msg) throws ModbusIOException {
        // If this isn't a Slave ID missmatch message
        if (msg.getAuxiliaryType().equals(ModbusResponse.AuxiliaryMessageTypes.UNIT_ID_MISSMATCH)) {
            logger.debug("Ignoring response not meant for us");
        } else {
            // We need to pause before sending the response
            waitBetweenFrames();

            // Send the response
            writeMessage(msg);
        }
    }

    @Override
    public void writeRequest(ModbusRequest msg) throws ModbusIOException {
        writeMessage(msg);
    }

    private void writeMessage(ModbusMessage msg) throws ModbusIOException {
        open();
        notifyListenersBeforeWrite(msg);
        writeMessageOut(msg);
        long startTime = System.nanoTime();

        // Wait here for the message to have been sent

        double bytesPerSec = commPort.getBaudRate() / (((commPort.getNumDataBits() == 0) ? 8 : commPort
                .getNumDataBits()) + ((commPort
                .getNumStopBits() == 0) ? 1 : commPort.getNumStopBits()) + ((commPort.getParity() == SerialPort
                .NO_PARITY) ? 0 : 1));
        double delay = 1000000000.0 * msg.getOutputLength() / bytesPerSec;
        double delayMilliSeconds = Math.floor(delay / 1000000);
        double delayNanoSeconds = delay % 1000000;
        try {

            // For delays less than a millisecond, we need to chew CPU cycles unfortunately
            // There are some fiddle factors here to allow for some oddities in the hardware

            if (delayMilliSeconds == 0.0) {
                int priority = Thread.currentThread().getPriority();
                Thread.currentThread().setPriority(Thread.MIN_PRIORITY);
                long end = startTime + ((int) (delayNanoSeconds * 1.3));
                while (System.nanoTime() < end) {
                    // noop
                }
                Thread.currentThread().setPriority(priority);
            } else {
                Thread.sleep((int) (delayMilliSeconds * 1.4), (int) delayNanoSeconds);
            }
        } catch (Exception e) {
            logger.debug("nothing to do");
        }
        notifyListenersAfterWrite(msg);
    }

    @Override
    public ModbusRequest readRequest(AbstractModbusListener listener) throws ModbusIOException {
        open();
        notifyListenersBeforeRequest();
        ModbusRequest req = readRequestIn(listener);
        notifyListenersAfterRequest(req);
        return req;
    }

    @Override
    public ModbusResponse readResponse() throws ModbusIOException {
        notifyListenersBeforeResponse();
        ModbusResponse res = readResponseIn();
        notifyListenersAfterResponse(res);
        return res;
    }

    private void open() throws ModbusIOException {
        if (commPort != null && !commPort.isOpen()) {
            setTimeout(timeout);
            try {
                commPort.open();
            } catch (IOException e) {
                throw new ModbusIOException(String.format("Cannot open port %s - %s",
                                                          commPort.getDescriptivePortName(),
                                                          e.getMessage()));
            }
        }
    }

    @Override
    public void setTimeout(int time) {
        super.setTimeout(time);
        if (commPort != null) {
            commPort.setComPortTimeouts(AbstractSerialConnection.TIMEOUT_READ_BLOCKING, timeout, timeout);
        }
    }

    abstract protected void writeMessageOut(ModbusMessage msg) throws ModbusIOException;

    abstract protected ModbusRequest readRequestIn(AbstractModbusListener listener) throws ModbusIOException;

    abstract protected ModbusResponse readResponseIn() throws ModbusIOException;

    public void addListener(AbstractSerialTransportListener listener) {
        if (listener != null) {
            listeners.add(listener);
        }
    }

    public void removeListener(AbstractSerialTransportListener listener) {
        if (listener != null) {
            listeners.remove(listener);
        }
    }

    public void clearListeners() {
        listeners.clear();
    }

    private void notifyListenersBeforeRequest() {
        synchronized (listeners) {
            for (AbstractSerialTransportListener listener : listeners) {
                listener.beforeRequestRead(commPort);
            }
        }
    }

    private void notifyListenersAfterRequest(ModbusRequest req) {
        synchronized (listeners) {
            for (AbstractSerialTransportListener listener : listeners) {
                listener.afterRequestRead(commPort, req);
            }
        }
    }

    private void notifyListenersBeforeResponse() {
        synchronized (listeners) {
            for (AbstractSerialTransportListener listener : listeners) {
                listener.beforeResponseRead(commPort);
            }
        }
    }

    private void notifyListenersAfterResponse(ModbusResponse res) {
        synchronized (listeners) {
            for (AbstractSerialTransportListener listener : listeners) {
                listener.afterResponseRead(commPort, res);
            }
        }
    }

    private void notifyListenersBeforeWrite(ModbusMessage msg) {
        synchronized (listeners) {
            for (AbstractSerialTransportListener listener : listeners) {
                listener.beforeMessageWrite(commPort, msg);
            }
        }
    }

    private void notifyListenersAfterWrite(ModbusMessage msg) {
        synchronized (listeners) {
            for (AbstractSerialTransportListener listener : listeners) {
                listener.afterMessageWrite(commPort, msg);
            }
        }
    }


    public void setCommPort(AbstractSerialConnection cp) throws IOException {
        commPort = cp;
        setTimeout(timeout);
    }

    public AbstractSerialConnection getCommPort() {
        return commPort;
    }

    public boolean isEcho() {
        return echo;
    }

    public void setEcho(boolean b) {
        this.echo = b;
    }

    public void setBaudRate(int baud) {
        commPort.setBaudRate(baud);
        logger.debug("baud rate is now {}", commPort.getBaudRate());
    }

    void readEcho(int len) throws IOException {
        byte echoBuf[] = new byte[len];
        int echoLen = commPort.readBytes(echoBuf, len);
        if (logger.isDebugEnabled()) {
            logger.debug("Echo: {}", ModbusUtil.toHex(echoBuf, 0, echoLen));
        }
        if (echoLen != len) {
            logger.debug("Error: Transmit echo not received");
            throw new IOException("Echo not received");
        }
    }


    protected int readByte() throws IOException {
        if (commPort != null && commPort.isOpen()) {
            byte[] buffer = new byte[1];
            int cnt = commPort.readBytes(buffer, 1);
            if (cnt != 1) {
                throw new IOException("Cannot read from serial port");
            } else {
                return buffer[0] & 0xff;
            }
        } else {
            throw new IOException("Comm port is not valid or not open");
        }
    }

    void readBytes(byte[] buffer, long bytesToRead) throws IOException {
        if (commPort != null && commPort.isOpen()) {
            int cnt = commPort.readBytes(buffer, bytesToRead);
            if (cnt != bytesToRead) {
                throw new IOException("Cannot read from serial port - truncated");
            }
        } else {
            throw new IOException("Comm port is not valid or not open");
        }
    }

    final int writeBytes(byte[] buffer, long bytesToWrite) throws IOException {
        if (commPort != null && commPort.isOpen()) {
            return commPort.writeBytes(buffer, bytesToWrite);
        } else {
            throw new IOException("Comm port is not valid or not open");
        }
    }

    int readAsciiByte() throws IOException {
        if (commPort != null && commPort.isOpen()) {
            byte[] buffer = new byte[1];
            int cnt = commPort.readBytes(buffer, 1);
            if (cnt != 1) {
                throw new IOException("Cannot read from serial port");
            } else if (buffer[0] == ':') {
                return ModbusASCIITransport.FRAME_START;
            } else if (buffer[0] == '\r' || buffer[0] == '\n') {
                return ModbusASCIITransport.FRAME_END;
            } else {
                logger.debug("Read From buffer: " + buffer[0] + " (" + String.format("%02X", buffer[0]) + ")");
                byte firstValue = buffer[0];
                cnt = commPort.readBytes(buffer, 1);
                if (cnt != 1) {
                    throw new IOException("Cannot read from serial port");
                } else {
                    logger.debug("Read From buffer: " + buffer[0] + " (" + String.format("%02X", buffer[0]) + ")");
                    int combinedValue = (Character.digit(firstValue, 16) << 4) + Character.digit(buffer[0], 16);
                    logger.debug("Returning combined value of: " + String.format("%02X", combinedValue));
                    return combinedValue;
                }
            }
        } else {
            throw new IOException("Comm port is not valid or not open");
        }
    }

    final int writeAsciiByte(int value) throws IOException {
        if (commPort != null && commPort.isOpen()) {
            byte[] buffer;

            if (value == ModbusASCIITransport.FRAME_START) {
                buffer = new byte[]{58};
                logger.debug("Wrote FRAME_START");
            } else if (value == ModbusASCIITransport.FRAME_END) {
                buffer = new byte[]{13, 10};
                logger.debug("Wrote FRAME_END");
            } else {
                buffer = ModbusUtil.toHex(value);
                if (logger.isDebugEnabled()) {
                    logger.debug("Wrote byte {}={}", value, ModbusUtil.toHex(value));
                }
            }
            if (buffer != null) {
                return commPort.writeBytes(buffer, buffer.length);
            } else {
                throw new IOException("Message to send is empty");
            }
        } else {
            throw new IOException("Comm port is not valid or not open");
        }
    }

    int writeAsciiBytes(byte[] buffer, long bytesToWrite) throws IOException {
        if (commPort != null && commPort.isOpen()) {
            int cnt = 0;
            for (int i = 0; i < bytesToWrite; i++) {
                if (writeAsciiByte(buffer[i]) != 2) {
                    return cnt;
                }
                cnt++;
            }
            return cnt;
        } else {
            throw new IOException("Comm port is not valid or not open");
        }
    }

    void clearInput() throws IOException {
        if (commPort.bytesAvailable() > 0) {
            int len = commPort.bytesAvailable();
            byte buf[] = new byte[len];
            readBytes(buf, len);
            if (logger.isDebugEnabled()) {
                logger.debug("Clear input: {}", ModbusUtil.toHex(buf, 0, len));
            }
        }
    }

    @Override
    public void close() throws IOException {
        commPort.close();
    }

    private void waitBetweenFrames() {
        waitBetweenFrames(0, 0);
    }


    void waitBetweenFrames(int transDelayMS, long lastTransactionTimestamp) {

        // If a fixed delay has been set
        if (transDelayMS > 0) {
            ModbusUtil.sleep(transDelayMS);
        } else {
            // Make use we have a gap of 3.5 characters between adjacent requests
            // We have to do the calculations here because it is possible that the caller may have changed
            // the connection characteristics if they provided the connection instance
            int delay = (int) (Modbus.INTER_MESSAGE_GAP * (commPort.getNumDataBits() + commPort.getNumStopBits()) *
                    1000 / commPort
                    .getBaudRate());

            // If the delay is below the miimum, set it to the minimum
            if (delay > Modbus.MINIMUM_TRANSMIT_DELAY) {
                delay = Modbus.MINIMUM_TRANSMIT_DELAY;
            }

            // How long since the last message we received
            long gapSinceLastMessage = System.currentTimeMillis() - lastTransactionTimestamp;
            if (delay > gapSinceLastMessage) {
                ModbusUtil.sleep(delay - gapSinceLastMessage);
            }
        }
    }

}
